#!/usr/bin/env python3.9
# This generates 'analogue' SSTV from a (preferable very high resolution)
# image.
# It's slow, and Python uses up a ridiculous amount of memory, but it's
# just accessible code for playing with.
# --
# Licenced according to the GNU Public Licence version 3.0
# Chris Baird,, June 2022 <vk2cjb@gmail.com>

import math, struct, sys
from PIL import Image

specs = [
    # name    type VIS scanlen   fudge    modesize
    ( "PD50", 'P', 93,  4394, 18632.85, (320,256)),
    ( "PD90", 'P', 99,  8173, 33748.00, (320,256)),
    ("PD120", 'P', 95,  5838, 24408.65, (640,496)),
    ("PD160", 'P', 98,  9389, 38614.60, (512,400)),
    ("PD180", 'P', 96,  8787, 36206.05, (640,496)),
    ("PD240", 'P', 97, 11736, 48003.25, (640,496)), # decodes poorly in QSSTV
    ("PD290", 'P', 94, 10984, 44992.50, (800,616))]
#   ( "MR73", 'R', 1,      0,        0, (320,256)),
#   ( "MR90", 'R', 2,      0,        0, (320,256)),
#   ("MR115", 'R', 3,      0,        0, (320,256)),
#   ("MR140", 'R', 4,      0,        0, (320,256)),
#   ("MR175", 'R', 5,      0,        0, (320,256)),
#   ( "MP73", 'P', 6,      0,        0, (320,256)),
#   ("MP115", 'P', 7,      0,        0, (320,256)),
#   ("MP140", 'P', 8,      0,        0, (320,256)),
#   ("MP175", 'P', 9,      0,        0, (320,256)),
#   ("ML180", 'L', 10,     0,        0, (640,496)),
#   ("ML240", 'L', 11,     0,        0, (640,496)),
#   ("ML280", 'L', 12,     0,        0, (640,496)),
#   ("ML320", 'L', 13,     0,        0, (640,496))]

colourbars = [
    (0.89, +0.055, -0.445), # Yellow (Y, Cr, Cb)
    (0.70, -0.350, +0.15),  # Cyan
    (0.59, -0.295, -0.295), # Green
    (0.41, +0.295, +0.295), # Magneta
    (0.30, +0.350, -0.15),  # Red
    (0.11, -0.055, +0.445) ]# Blue

colourbars75 = [(0,0,0)]*6
for i in range(6):
    y,r,b = colourbars[i]
    y, r, b = y*0.95, 0.5+r*0.95, 0.5+b*0.95
    colourbars75[i] = (y,r,b)

modelist = [x[0] for x in specs]
sample_rate, ph, nframes, mode, outfile = 48000, 0.0, 0, 4, ""
audio, bannerfile = bytearray(), ""

###

while len(sys.argv) > 1:
    sys.argv.pop(0)
    s = sys.argv[0]
    if s in modelist: mode = modelist.index(s)
    elif s == "-o":
        outfile = sys.argv[1]
        sys.argv.pop(0)
    elif s == "-b":
        bannerfile = sys.argv[1]
        sys.argv.pop(0)
    else:
        print ("You needed instructions? oh.")
        sys.exit(1)

(name, modetype, VIS, scanlen, fudge, modesize) = specs[mode]
height = modesize[1]
gridnum = 12
ny = height // gridnum
circlesize = 0.5 * height * 0.90

wwidth, woheight, depth = modesize[0] * 32, modesize[1], 65536.0

if len(bannerfile):
    im_orig = Image.open(bannerfile)
    width, oheight = im_orig.width, im_orig.height
    if im_orig.mode == "I": depth = 65536.0
    else: depth = 256.0

    ycbcr = im_orig.convert('YCbCr')
    y, cb, cr = ycbcr.split()
    imY = y.resize((width,1+(2*ny))).convert("F")
    imCb = cb.resize((width,1+ny)).convert("F") # xxxcjb
    imCr = cr.resize((width,1+ny)).convert("F")

    scanlinescale = width / (10*ny)

###

def sampleout(freq):
    global ph,nframes,audio
    s = int(math.sin(ph / sample_rate) * 3276.8) # constant can vary
    if s < 0: s += 65536
    audio += bytes([s & 255,s >> 8])
    ph += math.tau * freq
    nframes += 1


def tonegen(freq,samples):
    for _ in range(samples): sampleout(freq)

###

def vis(c):
    tonegen(1900,14400)  # leader - 1900 Hz, 300 ms/570 cycles/14400
    tonegen(1200,480)    # break - 1200 Hz,  10 ms/12 cycles/480
    tonegen(1900,14400)  # leader again
    tonegen(1200,1440)   # startbit - 1200 Hz, 30 ms/36 cycles

    p = 0
    for i in (1,2,4,8,16,32,64,128):
        if i == 128 and (p & 1): c |= 128
        if (c & i):
            p += 1
            tonegen(1100,1440)
        else:
            tonegen(1300,1440)
    tonegen(1200,1440)          # stop bit - 30 ms

###

def getvalue(sx,y):
    x = sx * scanlinescale
    r = (0,0,0)
    if (y <= 2*ny) and (x >= 0) and (x <= width):
        lum = imY.getpixel((x,y)) / depth
        cr = imCr.getpixel((x,y//2)) / depth
        cb = imCb.getpixel((x,y//2)) / depth
        r = (lum, cr, cb)
    return r

#

oldidsize = (30,5)
oldid = (0,1,0,1, 0,0,1,0,1, 0,0,1,1,1, 0,0,1,1,1, 0,0,0,0,1, 0,0,1,1,1,0,
         0,1,0,1, 0,0,1,0,1, 0,0,0,0,1, 0,0,1,0,0, 0,0,0,0,1, 0,0,1,0,1,0,
         0,1,0,1, 0,0,1,1,0, 0,0,1,1,1, 0,0,1,0,0, 0,0,0,0,1, 0,0,1,1,1,0,
         0,1,0,1, 0,0,1,0,1, 0,0,1,0,0, 0,0,1,0,0, 0,0,1,0,1, 0,0,1,0,1,0,
         0,0,1,0, 0,0,1,0,1, 0,0,1,1,1, 0,0,1,1,1, 0,0,1,1,1, 0,0,1,1,1,0)

idsize = (42,9)
id = (
    0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,
    0, 1,0,0,0,1, 0,0, 1,0,0,0,1, 0,0, 0,1,1,1,0, 0,0, 0,1,1,1,0, 0,0, 0,0,1,1,1, 0,0, 1,1,1,1,0, 0,
    0, 1,0,0,0,1, 0,0, 1,0,0,1,0, 0,0, 1,0,0,0,1, 0,0, 1,0,0,0,1, 0,0, 0,0,0,0,1, 0,0, 1,0,0,0,1, 0,
    0, 1,0,0,0,1, 0,0, 1,0,1,0,0, 0,0, 0,0,0,0,1, 0,0, 1,0,0,0,0, 0,0, 0,0,0,0,1, 0,0, 1,0,0,0,1, 0,
    0, 1,0,0,0,1, 0,0, 1,1,0,0,0, 0,0, 0,1,1,1,0, 0,0, 1,0,0,0,0, 0,0, 0,0,0,0,1, 0,0, 1,1,1,1,0, 0,
    0, 1,0,0,0,1, 0,0, 1,0,1,0,0, 0,0, 1,0,0,0,0, 0,0, 1,0,0,0,0, 0,0, 0,0,0,0,1, 0,0, 1,0,0,0,1, 0,
    0, 0,1,0,1,0, 0,0, 1,0,0,1,0, 0,0, 1,0,0,0,0, 0,0, 1,0,0,0,1, 0,0, 1,0,0,0,1, 0,0, 1,0,0,0,1, 0,
    0, 0,0,1,0,0, 0,0, 1,0,0,0,1, 0,0, 1,1,1,1,1, 0,0, 0,1,1,1,0, 0,0, 0,1,1,1,0, 0,0, 1,1,1,1,0, 0,
    0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0,0, 0,0,0,0,0, 0)

#

def doline(y,channel):
    dy = int(y - (height / 2))
    iy = abs(dy/ny)
    d = 0
    echopoint = 0
    for x in range(scanlen):
        xo = x - int(scanlen / 2)
        dx = (xo * height * modesize[0]) / (modesize[1]*scanlen)
        ix = abs(dx/ny)

        p = []
        p.append((0, 0,0.5,0.5)) # black background - lowest priority

        # checker grid - layer 1
        x1 = int(dx / ny)
        if dx > 0: x1 += 1
        y1 = int(dy / ny)
        if dy > 0: y1 += 1
        if ((x1 + y1) & 1) == 0: p.append((1, 0.5,0.5,0.5))

        # grid lines - layer 2 (or 30)
        l = 2
        if iy < 1.5: l = 30
        if ix > 0.5 and l == 30: l = 2
        if iy < 0.5 and l == 2: l = 30
        if ix > 3.5 and l == 30: l = 2
        if dy % ny == 0:
            p.append((l, 1.0,0.5,0.5))
        if (dx % ny <= 1) or (dx % ny) >= (ny-1):
            p.append((l, 1.0,0.5,0.5))

        # circle backing - layer 5
        if (y1 >= -3) and (y1 <= 4) and (x1 >= -5) and (x1 <= 6):
            p.append((5, 0.66, 0.5+0.2609, 0.5-0.2609))

        # circle - layer 10
        circleon = False
        r = math.sqrt(dx*dx + dy*dy)
        if r <= circlesize:
            circleon = True
            p.append((10, 0.66, 0.38049, 0.619501))

        # greyscale & colour blocks
        if circleon and y1 >= -1 and y1 <= 2:
            i = int(4 + ((dx-ny*2) / (ny*2)))
            if i < 0: i = 0
            if i > 5: i = 5
            if y1 < 1:
                lu, cr, cb = colourbars75[i]
            else:
                lu, cr, cb = 0.2*i, 0.5, 0.5
            p.append((20, lu, cr, cb))

        # grating band - layer 20
        if circleon and (y1 == 3 or y1 == 4):
            nx = scanlen // 8
            fx = ((y1-2)*16*gridnum*modesize[0]/scanlen)*(x / nx)
            #i = 0.5 + math.sin((fx*math.tau*x)/scanlen)
            # 1.82 is so PD160 (best horz definition) greys-out at the last square
            i = (math.pow((dx/ny)+(gridnum/2), 1.82)) * (y1-2)
            i = i % 2.0
            if i > 1.0: i = 2.0 - i
            p.append((20, i, 0.5,0.5))

        # ident or echotest background - layer 25
        if (y1 == -4 or y1 == 5) and x1 >= -1 and x1 <= 2:
            yt = (dy+ny*5) / (ny/idsize[1])
            xx = (idsize[0]*(dx+ny*2)) / (4 * ny)
            i = int(xx) + int(yt)*idsize[0]
            v = 0.0
            if y1 > 0:
                if abs(dx+ny) < 1:
                    v = 1.0
                    echopoint = nframes
                    # 10 ms: sound travels 3.5 m
                if abs((nframes-echopoint) - int(sample_rate/100)) < 4: v = 1.0
            elif i < len(id) and id[i]: v = 1.0
            p.append((25, v, 0.5,0.5))

        # station logo area - layer 26
        if circleon and (y1 == -3 or y1 == -2) and len(bannerfile):
            lum, cr, cb = getvalue(dx+ny*5,dy+ny*4)
            if lum != 0: p.append((26, lum, cr, cb))

        # find the highest layer
        k = sorted(p, reverse=True)[0]
        d = 1500 + (k[channel] * 800)
        sampleout(d)
    return d

### Main

tonegen(0,24000)
vis(VIS)

identstart, f, t = height * 0.80, 0, 0

for y in range(0,height,2):
    print ('\r',y, end='')
    f = int(y*fudge*0.5 - t)
    onf = nframes
    tonegen(1200,960)
    tonegen(1500,96)

    doline(y, 1)
    doline(y, 2)
    doline(y, 3)
    s = doline(y+1, 1)
    tonegen(s, f)

    t += nframes - onf

tonegen(0,24000)

###

if not outfile: outfile = "tve-" + name + ".wav"

wav = open(outfile,"wb")

s = struct.Struct(b"<ccccLccccccccLHHLLHHccccL")
wav.write(s.pack(b'R',b'I',b'F',b'F', # riff_header
                 2*len(audio)+44,     # wav_size
                 b'W',b'A',b'V',b'E', # wave_header
                 b'f',b'm',b't',b' ', # fmt_header
                 16,                  # fmt_chunk_size
                 1,                   # audio_format = PCM
                 1,                   # num_channels
                 sample_rate,
                 sample_rate*1*2,     # byte_rate
                 2,                   # sample_alignment
                 16,                  # bit_depth
                 b'd',b'a',b't',b'a',
                 2*len(audio)))       # data_bytes
wav.write(audio)                      # thanks to bytearray
wav.close()

###
